Skip to content

Percent-decode username and password parsed from URLs (#1871) - #2114

Open
mokashang wants to merge 2 commits into
fsspec:masterfrom
mokashang:fix/unquote-url-userinfo
Open

mokashang wants to merge 2 commits into
fsspec:masterfrom
mokashang:fix/unquote-url-userinfo

Conversation

@mokashang

Copy link
Copy Markdown
Contributor

Fixes #1871

Problem

infer_storage_options returned parsed_path.username and
parsed_path.password verbatim from urllib.parse.urlsplit, which does not
decode percent-encoded characters in the userinfo component. When a user is
forced to percent-encode a reserved character in the URL — the classic case
being # in a password, which would otherwise be parsed as the fragment
delimiter — the backend receives the literal encoded string:

>>> fsspec.open_files("sftp://user:pass%23with%23hash@host/path")
# password reaching paramiko: "pass%23with%23hash"  (auth fails)

The two reports on #1871 both hit this from the FTP/SFTP side, and every
infer_storage_options consumer that maps username/password straight into
its backend client — FTPFileSystem, SFTPFileSystem, SMBFileSystem,
WebHDFS, arrow — has the same failure mode.

Change

Percent-decode both fields with urllib.parse.unquote before returning them:

-        if parsed_path.username:
-            options["username"] = parsed_path.username
-        if parsed_path.password:
-            options["password"] = parsed_path.password
+        if parsed_path.username:
+            options["username"] = unquote(parsed_path.username)
+        if parsed_path.password:
+            options["password"] = unquote(parsed_path.password)

unquote returns its input unchanged when there is nothing to decode, so
URLs with plain-ASCII credentials (which every existing test uses) are
unaffected. The HTTP/HTTPS branch short-circuits at line 87 and never reaches
this code, so requests continues to handle its own URL parsing as noted in
the comment.

Regarding @martindurant's caution on the issue ("SSH would not expect to
have encoded strings"): SSH/SFTP/FTP servers do not implement URL decoding
themselves — they compare raw credentials — so decoding on our side is what
lets an encoded URL work at all. The @Jeansidharta reproducer is exactly this
case: paramiko is handed pass%23with%23hash%23char and rejects it.

Test plan

  • Added test_infer_options_percent_encoded_userinfo covering both the
    encoded case (user%40corp / p%23ass%2Fword%20!) and the unencoded
    passthrough case.
  • pytest fsspec/tests/test_utils.py — 99 passed.
  • pytest fsspec/tests/ fsspec/implementations/tests/ with backend suites
    that need network/cloud services excluded — 1553 passed, 176 skipped,
    2 xfailed.
  • ruff check / ruff format --check clean on the touched files.
  • Changelog entry added under the Dev section.

@martindurant

Copy link
Copy Markdown
Member

I think this is a fine idea; but I worry that some who have "%" in their username/pw will not get errors and have to add the extra step to encode first.

@martindurant

Copy link
Copy Markdown
Member

The @Jeansidharta reproducer is exactly this
case: paramiko is handed pass%23with%23hash%23char and rejects it.

Can you explain this a bit more, please?

@Jeansidharta

Copy link
Copy Markdown

The @Jeansidharta reproducer is exactly this
case: paramiko is handed pass%23with%23hash%23char and rejects it.

Can you explain this a bit more, please?

Hi @martindurant. I believe the original comment this quote came from should clarify. In short, I was trying to connect to a SFTP server using a password that contains a # character, using fsspect.open_files with a URL, like this:

fsspec.open_files("sftp://username:pass#with#hash#char@ftpserver.com/path")

But this fails, as the # character in the password is treated as a fragment separator. I then tried quoting my password:

from urllib.parse import quote

fsspec.open_files(f"sftp://username:{quote("pass#with#hash#char")}@ftpserver.com/path")

But, unfortunately, urllib.urlsplit does not unquote the netloc part, and fsspec does not handle this case either. My password was sent as pass%23with%23hash%23char directly to paramiko, which caused an auth error. I believe it is currently impossible to connect to an sftp server with this password using just the URL. I had to pass these values through kwargs:

fsspec.open_files("sftp://ftpserver.com/path", username="username", password="pass#with#hash#char")

This is a valid workaround for sftp, but is not guaranteed to work with all backends, as they may not use the username and password kwargs (examples: WebHDFS uses user, and GithubFileSystm uses token instead of password)

As has been mentioned, this change would be a silently breaking change for some users; the worst kind of change. Maybe it could be implemented as an optional argument to open_files and open, such as unquote_url, or maybe as a different function altogether?

`infer_storage_options` was returning `parsed_path.username` and
`parsed_path.password` verbatim from `urllib.parse.urlsplit`, which does
not decode percent-encoded characters in the userinfo component.  When a
user was forced to percent-encode a reserved character in the URL —
e.g. `sftp://user:pass%23with%23hash@host/path` to keep `#` out of the
fragment — the FTP/SFTP/SMB backends then received the literal
`pass%23with%23hash` and authentication failed.

Route both fields through a small `_unquote_userinfo` helper that runs
`urllib.parse.unquote(..., errors='strict')` and falls back to the raw
input on `UnicodeDecodeError`.  URLs without percent-encoded userinfo
are unaffected (`unquote` returns the input unchanged when there is
nothing to decode), and passwords with a bare `%` that happens to be
followed by two hex digits producing bytes outside UTF-8 — e.g. the
literal `pass%ab` password from a pre-existing URL — are preserved
rather than corrupted to U+FFFD or raising downstream, which addresses
the backwards-compat concern raised in review.

Adds a regression test covering the encoded case, the plain
passthrough, and three shapes of literal `%` in passwords.
@mokashang
mokashang force-pushed the fix/unquote-url-userinfo branch from d440103 to 90befa0 Compare September 18, 2026 00:59
@mokashang

Copy link
Copy Markdown
Contributor Author

@martindurant — good point, and I agree the silent-mismatch case is worth softening. The residual risk is a user whose password contains a bare % followed by two hex digits: urlsplit accepts that (urllib never enforces RFC-3986 escaping in the netloc), but unquote on the default errors='replace' would decode it and, when the byte is not valid UTF-8, produce a U+FFFD instead of an error.

Pushed 90befa0 to route both fields through a small _unquote_userinfo helper that uses errors='strict' and falls back to the raw string on UnicodeDecodeError:

def _unquote_userinfo(value: str) -> str:
    try:
        return unquote(value, errors="strict")
    except UnicodeDecodeError:
        return value

Net behavior on realistic passwords:

Input (raw netloc) Old fsspec This PR
plainpw plainpw plainpw
pass%23hash (encoded #) pass%23hash (broken) pass#hash
50%off (bare %, non-hex tail) 50%off 50%off
pass% (trailing %) pass% pass%
pass%ab (bare % + hex → invalid UTF-8) pass%ab pass%ab (fallback)
50%25off (RFC-encoded %) 50%25off (broken) 50%off

The only remaining ambiguity is the very last row — a user whose raw password literally is 50%25off (four printable chars including %, 2, 5) has always been in the position of needing to double-encode it in a URL. That's inherent to using RFC-3986 percent-encoding for anything at all, and there's no local information here that can distinguish it from someone who correctly encoded 50%off as 50%25off.

Regression test extended to cover 50%off, pass%, and pass%ab alongside the encoded case, and the changelog entry now names the fallback. Rebased onto master while I was in there.

@itzzdev09 itzzdev09 left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The fallback is all-or-nothing, so one undecodable escape discards the decoding of every valid escape in the same value. sftp://user%40corp:p%40ss%ab@h/f yields the password p%40ss%ab, where the %40 should have become @. The caller gets a wrong password with no error, which is the failure this PR is fixing.

Decoding escape by escape keeps each valid one and leaves only the undecodable bytes literal:

def _unquote_userinfo(value: str) -> str:
    def one(match: re.Match) -> str:
        try:
            return unquote(match.group(0), errors="strict")
        except UnicodeDecodeError:
            return match.group(0)

    return re.sub("%[0-9A-Fa-f]{2}", one, value)

The three backwards-compatibility cases in the test still pass with that, and a mixed value decodes the part that can be decoded. Worth a test for the mixed case either way.

@martindurant

Copy link
Copy Markdown
Member

I'm not sure about that. Wouldn't you say: the string is either encoded or not?

@itzzdev09

Copy link
Copy Markdown
Contributor

Fair point, and you're right in principle: per RFC 3986 the userinfo is percent-encoded, so a % in a password should always arrive as %25 and the string is encoded, full stop. My per-escape idea invents a third mode that is neither, and it would be hard to document.

So the question is only what to do with URLs that were never encoded, which is what the fallback exists for. Decoding unconditionally is the clean rule, and it does change behaviour for anyone passing 50%off today and meaning it literally. If that is acceptable, drop the fallback and the _unquote_userinfo helper with it, call unquote(...) on both fields, and say in the docstring that userinfo must be encoded; the pass%ab case then becomes a genuine error rather than something to paper over.

If that compatibility is worth keeping, the current whole-value fallback is the better of the two shims, since "it decodes, or it is left exactly as it came" is at least a rule you can state in one line. Either way I withdraw the per-escape suggestion.

@martindurant

Copy link
Copy Markdown
Member

The compatibility is worth keeping

@itzzdev09 itzzdev09 left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Understood, then the PR as it stands is the right shape and my suggestion is moot.

One line worth adding to _unquote_userinfo while it is there: say the fallback is deliberate, i.e. a value is decoded as a whole or kept exactly as it arrived, so that a later reader does not "fix" the mixed case into partial decoding. The three cases in the test already pin the behaviour.

@martindurant

Copy link
Copy Markdown
Member

A comment is fine if it is short!

Reword the comment above `_unquote_userinfo` so a later reader sees at
a glance that the fallback is deliberate and never partial: the value
is either decoded in full or returned exactly as it arrived. The three
cases in `test_infer_storage_options_percent_encoded_userinfo` and
`test_infer_storage_options_userinfo_bare_percent_preserved` already
pin this behaviour; the comment names it so nobody "fixes" it into
partial decoding later.
@mokashang

Copy link
Copy Markdown
Contributor Author

Reworded the comment above _unquote_userinfo (233195c) to name the all-or-nothing behavior explicitly — same 5 lines, no functional change:

def _unquote_userinfo(value: str) -> str:
    # Percent-decode a URL userinfo component (username or password).
    # The fallback is deliberately all-or-nothing: the value is either decoded
    # in full or kept exactly as it arrived, so a literal ``%`` followed by two
    # hex digits that happens to produce a non-UTF-8 byte (e.g. a password
    # containing ``%ab``) is preserved intact instead of being replaced by
    # U+FFFD, and no caller ever sees a partially-decoded string.
    try:
        return unquote(value, errors="strict")
    except UnicodeDecodeError:
        return value

The three-case test matrix (%23/%40/%c3%a9 decode, 50%off/pass%/pass%ab preserve) still pins the behavior; the comment just makes the intent hard to miss.

@martindurant

Copy link
Copy Markdown
Member

I was hoping for a short comment: one line only

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Unquote Username and Password

4 participants